Сразу дисклеймер, статья больше про Golang, но мой «родной» и основной на протяжении уже 6 лет — Kotlin — буду рад если будут замечания по Golang части в комментариях
Немного о себе — системный архитектор компании SpectrumData, тут вроде как по канонам хабра ни рекламы ничего давать нельзя, но есть канал по программированию у нас — можете найти — может что есть интересного. В архитекторах я оказался из разработчиков, стаж более 20 лет на разных платформах и задачах. Сейчас тоже стараюсь и сам писать и есть команды разработчиков в подчинении.
Никогда не писал на хабре. Обычно если какой-то материал появлялся, то для внутренних нужд или лучше смотрится в ролике. Но тут материала подкопилось текстового. Решил написать статью. Может кому-то будет интересно.
Так уж получилось, что у нас в компании используются разные стеки и языки. И в частности у нас есть большое подразделение, основным стеком которого является JVM с Kotlin в качестве языка разработки (вместо ванильной Java, на бэкенде). Но при этом этому же отделу регулярно приходится использовать в работе GoLang. В частности бывают кейсы:
-
портирования кода (в обе стороны)
-
реализации каких-то компонентов сразу на 2-х языках (в основном это внутренние SDK)
Сразу скажу — почему эта задача вообще в целом для нас легкая и подъемная — наши бэкенды на Kotlin строятся на микрофреймворках типа Ktor, а не на Spring или не дай бог JavaEE, соответственно тяжелых вопросов соответствия каких-то лютых Enterprise монструозных JAR каким-то решениям в Golang не стоит.
Естественно, что мы сейчас говорим про языки и соответствие КОНСТРУКЦИЙ , а не про библиотеки или фреймворки.
Ну и некоторых ставит поначалу в ступор, что Kotlin/JVM это «про классы» и «не натив», а Golang это вроде как «процедурный стиль» и «натив».
На деле практически все довольно органично воспроизводится. В этой статье приведу некоторые примеры взаимозаменяемых конструкций и хаков. Материал в основном для тех кто пишет на Kotlin и для кого Golang — второй язык.
Сразу оговорюсь, что при переносе можно выделить несколько ситуаций, под одну гребенку все невозможно завести.
Простые:
-
перенос один в один или очень близко в той же структуре кода (без учета непосредственно синтаксиса языка) с сохранением канона (каноничного стиля кода GoLang) — да почти все
-
перенос один в один или через хаки, но без сохранения канона — код и API в итоге очень похоже на Kotlin, но при этом код не каноничен для GoLang — например статические методы, синглтоны, компаньоны
-
нельзя перенести один в один, но есть канонические легко осваиваемые паттерны которые «по духу» и смыслу аналогичны Kotlin — как ни странно — почти все ООП воспроизводится без особых потерь на структурах без классов
Тяжелые:
-
требуют пересмотра парадигмы языка и переноса не в лоб, требуют хорошего понимания концепций обоих языков — например соответствие пакетов в JVM и распределения кода и пакетов в Golang, любые переносы решений с большим использование корутин (они обманчиво похожи на горутины, но требуют иного планирования)
-
можно перенести только хаками, при этом код на выходе плохой, не каноничный и при этом не до конца полный по смыслу и духу — это попытки полностью реализовать перечисления как классы, тотальную иммутабельность и защиту от NPE, решения сильно завязанные на рефлексию и т.п.
-
вообще нельзя сделать идентичным — не так много таких вещей, но по факту это все, что сильно завязано на генериках в JVM понимании и сахарная функциональщина типа DSL
List<T>.filter vs List<T>.map
Начнем с примера в котором сразу будут показаны некоторые типовые переносы и один невозможный перенос.
Итак мы хотим перенести в Go функциональную обработку коллекций (а там этого явно не хватает, понятно есть какие-то внешние пакеты, но допустим хотим свое)
fun <T> List<T>.filter(condition : (T)->Boolean): List<T> { return buildList { for (item in this) { if (condition(item)){ add(item) } } } } fun <T,R> List<T>.map(mapper : (T)->R): List<R> { return buildList { for (item in this) { add(mapper(item)) } } }
И вот мы начинаем воспроизводить
Во-первых мы хотим это исполнить именно как метод, а не как функцию, чтобы их делать в цепь l.Filter().Filter().Map().First()
, а не вкладывать First( Map( Filter ( Filter(l)))
Пробуем решить в лоб (не получится)
// пробуем навесить метод прямо на срез func (s []any) Filter(condition func(item any) bool) []any
Сразу куча проблем — во-первых так нельзя — навешивать функции на чужие типы, во-вторых у нас резко теряется информация о типе!
// пробуем сделать generic-метод func (s []T) Filter[T any](condition func(item T) bool) []any
а так тем более нельзя — потому что вообще нет GENERIC методов в Golang, не завезли, функции есть, а методов — нет!
Но тут на помощь приходит то, что по своей природе Golang — это в своей основе C, где нет аьясов типа, а есть создание типа на основе данного. Вот так можно:
type List[T any] []T func (l List[T]) Filter(condition func(item T) bool) List[T]
Итак — первое, что уже можно выучить — нельзя навесить «расширение» на уже кем-то в другом пакете написанную структуру, но можно сделать тип в своем пакете, эквивалентный целевому и сделать метод уже у него!
но так просто это использовать не получится, потребуется:
// так не получится ([]int{1,2,3}).Filter(func(item int) bool {return item > 1}) // а вот так да: List[int]([]int{1,2,3}).Filter(func(item int) bool {return item > 1})
не красивый повтор параметра типа…, немного усовершенствуем:
// сделали а-ля приватную структуру, которую снаружи в явном виде создать нелья // но в отличие от Kotlin можно ВОЗВРАЩАТЬ type _ListType[T any] []T // навесили на нее наш метод func (l _ListType[T]) Filter(condition func(item T) bool) _ListType[T] {...} // сделали "конструктор" func List[T](l []T) _ListType[T] { return _ListType[T](l) } // теперь сработает автовывод типа mylist := List([]int{1,2,3}).Filter(...) // _ListType[int]
Заодно приведем вариант реализации этого Filter
, вдруг она кому-то не очевидна
func (l _ListType[T]) Filter(condition func(item T) bool) _ListType[T] { var result []T for _, item := range l { // _ListType[T] все еще []T if condition(item) { result = append(result, item) } } return result // автоматический апкаст до _ListType[T] автоматически }
Окрыленные своим успехом, мы без проблем реализуем такие методы как Take, TakeLast, Drop, DropLast , First, FirstOrDefault…
Кстати а как сделать FirstOrDefault()?
И тут как это ни странно в Java/Kotlin, при всем богатстве рефлексии — это сложно, так как не очень понятно как именно в общем случае (не в частном, а общем) получить дефолтный экземпляр некоего типа T !!! Вот, что примерно бы было в Kotlin:
fun <T: Any> List<T>.firstOrDefault(): T { if (this.size > 0) return this[0] // тут все просто, а вот дальше... // все, приплыли } // немного переделаем fun <T: Any> List<T>.firstOrDefault(clazz : KClass<T> ): T { if (this.size > 0) return this[0] // тут все просто, а вот дальше... return clazz.createInstance() // ну и мы понимаем, что это ни разу не общее решение и с кучей типов // это не сработает как надо !!! } // добавим сахара inline fun <reified T:Any> List<T>.firstOrDefault(): T = this.firstOrDefault(T::class)
В Golang это решается проще и можно запомнить идиому:
func (l _ListType[T]) FirstOrDefault() T { if len(l) > 0 { return l[0] } var def T // просто определяем переменную! // и так как в GO все переменные инициализируются дефолтным значением, // например 0, "", nil, пустая структура - то все, вуаля - можно возвращать return def }
Зато сложнее получить в общем случае поведение DefaultOrNil(), которое в Kotlin несколько проще достигается… ну это уже совсем нюансы
Итак — второй «хак» — в Golang легко получить дефолт любого типа ,
просто определив переменную этого типа
И вот мы очень все еще окрылены нашим успехом переноса функциональщины, частично генериков и расширений и все идет как надо….
Более того все переносы они даже и канонов каких-то особых не нарушают и читаются легко.
Но тут мы резко и без предупреждения споткнемся о такой простой метод как List.map,
напомню его код:
fun <T,R> List<T>.map(mapper : (T)->R): List<R> { return buildList { for (item in this) { add(mapper(item)) } } }
Пытаемся в лоб:
func (l _ListType[T]) Map[R any] (mapper func(src T) R) _ListType[R] { var result []R for _, item := range l { result = append(result, mapper(item)) } return result }
И тут мы упремся в короткое и лаконичное сообщение компилятора Golang:
syntax error: method must have no type parameters
О как! Обычные функции могут иметь тип-параметры , а методы (у которых есть ресивер) — нет! И более того нет никаких признаков, что их в ближайшее время завезут(!!!).
И вот тут мы напарываемся на первую преграду действительно серьезную:
Шаблоны (генерики) в Golang намного слабее и не идут ни в какое сравнение по мощности и выразительности ни с Java/Kotlin ни тем более с Rust или с теми же шаблонами C++. Если ваше решение сильно завязано на генерики и они есть как у классов, так и у методов или расширений — скорее всего это та грань и та черта проекта, которая будет практически невозможно перенести на Golang без потерь в эргономике или семантике!!!
И получается, что в рамках нашей задумки вполне можно реализовать методы, которые не требуют второго генерика и не получится нормально тех, которые требуют (Map, Zip, частично Fold, Reduce).
Соответственно мы можем реализовать Map , Fold, Reduce только в варианте с тем же типом, но не в обобщенной форме, то есть на вход List<T> и на выход List<T> или T, но не List<R>, R:
func (l _ListType[T]) Map (mapper func(item T) T) _ListType[T] { var result []T for _, item := range l { result = append(result, mapper(item)) } return result }
В таком виде естественно будет работать — но очевидно что это не тот Map
о котором мы джва года уже мечтали…
Соответственно какие выводы можно сделать:
-
в целом нет